Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit a1e3799af58fac961f81f7ad55d6f4b9f884f6a7


Parents : 283e83c
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-06-16T19:07:52-05:00

feat(pathfinding): update pathfinding UI and logic in Conversation components, adding loading states and improved snapshot handling

Changes
Diff

diff --git a/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue b/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
index 4ac6c0dd..99c63796 100644
--- a/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
@@ -46,7 +46,7 @@
<div
v-if="
- selectedPeerPath ||
+ showPeerPathRow ||
selectedPeerSignalMetrics?.snr != null ||
selectedPeerLxmfStampInfo?.stamp_cost ||
lxmfHasOutboundTicket
@@ -57,15 +57,18 @@
<div class="flex items-center gap-2 truncate">
<span
- v-if="selectedPeerPath"
- class="flex items-center cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200 shrink-0"
- title="Path information"
- @click="$emit('destination-path-click', selectedPeerPath)"
+ v-if="showPeerPathRow"
+ class="flex items-center gap-1 shrink-0"
+ :class="peerPathRowClass"
+ :title="peerPathRowTitle"
+ @click="onPeerPathRowClick"
>
- <span v-if="selectedPeerPath.hops === 0 || selectedPeerPath.hops === 1">{{
- $t("messages.direct")
- }}</span>
- <span v-else>{{ $t("messages.hops_away", { count: selectedPeerPath.hops }) }}</span>
+ <MaterialDesignIcon
+ v-if="peerPathBusy"
+ icon-name="loading"
+ class="size-3.5 animate-spin shrink-0"
+ />
+ <span>{{ peerPathRowLabel }}</span>
</span>
<span v-if="selectedPeerSignalMetrics?.snr != null" class="flex items-center gap-2 shrink-0">
@@ -208,6 +211,18 @@ export default {
type: Object,
default: null,
},
+ peerPathSnapshot: {
+ type: Object,
+ default: null,
+ },
+ peerPathLoading: {
+ type: Boolean,
+ default: false,
+ },
+ peerPathWarming: {
+ type: Boolean,
+ default: false,
+ },
selectedPeerSignalMetrics: {
type: Object,
default: null,
@@ -242,6 +257,60 @@ export default {
destinationDisplay() {
return Utils.formatDestinationHash(this.selectedPeer?.destination_hash);
},
+ peerPathBusy() {
+ return this.pathfinderInProgress || this.peerPathWarming;
+ },
+ showPeerPathRow() {
+ return (
+ this.peerPathBusy ||
+ this.peerPathLoading ||
+ this.selectedPeerPath != null ||
+ (this.peerPathSnapshot != null && !this.selectedPeerPath)
+ );
+ },
+ peerPathRowLabel() {
+ if (this.peerPathBusy) {
+ return this.$t("messages.outbound_pathfinding_short");
+ }
+ if (this.peerPathLoading && !this.selectedPeerPath) {
+ return this.$t("messages.path_loading");
+ }
+ if (this.selectedPeerPath) {
+ let label =
+ this.selectedPeerPath.hops === 0 || this.selectedPeerPath.hops === 1
+ ? this.$t("messages.direct")
+ : this.$t("messages.hops_away", { count: this.selectedPeerPath.hops });
+ if (this.peerPathSnapshot?.path_stale) {
+ label += ` (${this.$t("messages.path_stale_label")})`;
+ } else if (this.peerPathSnapshot?.path_unresponsive) {
+ label += ` (${this.$t("messages.path_unresponsive_label")})`;
+ }
+ return label;
+ }
+ return this.$t("messages.path_no_route");
+ },
+ peerPathRowClass() {
+ const base = "cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200";
+ if (this.peerPathBusy) {
+ return `${base} text-blue-600 dark:text-blue-400`;
+ }
+ if (!this.selectedPeerPath) {
+ return `${base} text-amber-700 dark:text-amber-400`;
+ }
+ if (this.peerPathSnapshot?.path_stale || this.peerPathSnapshot?.path_unresponsive) {
+ return `${base} text-amber-700 dark:text-amber-400`;
+ }
+ return base;
+ },
+ peerPathRowTitle() {
+ if (this.peerPathBusy) {
+ return this.$t("messages.outbound_pathfinding_tooltip");
+ }
+ if (!this.selectedPeerPath) {
+ return this.$t("messages.path_no_route_hint");
+ }
+ return this.$t("messages.path_info_title");
+ },
lxmfHasOutboundTicket() {
return this.selectedPeerLxmfStampInfo?.outbound_ticket_expiry != null;
},
@@ -271,5 +340,12 @@ export default {
return dayjs(ms).fromNow();
},
},
+ methods: {
+ onPeerPathRowClick() {
+ if (this.selectedPeerPath) {
+ this.$emit("destination-path-click", this.selectedPeerPath);
+ }
+ },
+ },
};
</script>

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index a4fb980f..e79dd9c1 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -26,6 +26,9 @@
:has-failed-or-cancelled-messages="hasFailedOrCancelledMessages"
:message-icon-style="messageIconStyle"
:selected-peer-path="selectedPeerPath"
+ :peer-path-snapshot="peerPathSnapshot"
+ :peer-path-loading="peerPathLoading"
+ :peer-path-warming="peerPathWarming"
:selected-peer-signal-metrics="selectedPeerSignalMetrics"
:selected-peer-lxmf-stamp-info="selectedPeerLxmfStampInfo"
:pathfinder-in-progress="pathfinderInProgress"
@@ -1732,7 +1735,13 @@ import ConversationMessageEntry from "./ConversationMessageEntry.vue";
import ConversationMessageListVirtual from "./ConversationMessageListVirtual.vue";
import { displayGroupsOldestFirst, MIN_VIRTUAL_DISPLAY_GROUPS } from "./messageListVirtual.js";
import DialogUtils from "../../js/DialogUtils";
-import { getDestinationPath, postRequestPath, runDestinationPathFinder } from "../../js/reticulumPathfinding.js";
+import {
+ fetchPeerPathSnapshot,
+ normalizePathSnapshot,
+ pathNeedsRefresh,
+ runDestinationPathFinder,
+ warmPathIfNeeded,
+} from "../../js/reticulumPathfinding.js";
import MicrophoneRecorder from "../../js/MicrophoneRecorder";
import WebSocketConnection from "../../js/WebSocketConnection";
import AddAudioButton from "./AddAudioButton.vue";
@@ -1812,7 +1821,10 @@ export default {
data() {
return {
GlobalState,
- selectedPeerPath: null,
+ peerPathSnapshot: null,
+ peerPathLoading: false,
+ peerPathWarming: false,
+ peerPathRequestSequence: 0,
selectedPeerLxmfStampInfo: null,
selectedPeerSignalMetrics: null,
pathfinderInProgress: false,
@@ -1941,6 +1953,9 @@ export default {
compactPeerActions() {
return this.windowWidth < 640 || this.peerHeaderCompact;
},
+ selectedPeerPath() {
+ return this.peerPathSnapshot?.path ?? null;
+ },
compactSendLayout() {
return this.windowWidth < 640 || this.peerHeaderCompact;
},
@@ -3104,7 +3119,9 @@ export default {
this.messagesViewportReady = false;
this.chatItems = [];
this.hasMorePrevious = true;
- this.selectedPeerPath = null;
+ this.peerPathSnapshot = null;
+ this.peerPathLoading = false;
+ this.peerPathWarming = false;
this.selectedPeerLxmfStampInfo = null;
this.selectedPeerSignalMetrics = null;
if (!this.selectedPeer) {
@@ -3116,10 +3133,9 @@ export default {
await this.$nextTick();
this.resetStaleConversationScrollSurface();
- this.getPeerPath();
+ this.refreshPeerPath({ warm: true });
this.getPeerLxmfStampInfo();
this.getPeerSignalMetrics();
- this.warmPathToPeer();
this.markConversationAsRead(this.selectedPeer);
@@ -3349,7 +3365,7 @@ export default {
case "announce": {
// update stamp info and signal metrics if an announce is received from the selected peer
if (json.announce.destination_hash === this.selectedPeer?.destination_hash) {
- await this.getPeerPath();
+ await this.refreshPeerPath({ warm: true });
await this.getPeerLxmfStampInfo();
await this.getPeerSignalMetrics();
}
@@ -3357,13 +3373,13 @@ export default {
}
case "lxmf.delivery": {
this.onLxmfMessageReceived(json.lxmf_message);
- await this.getPeerPath();
+ await this.refreshPeerPath({ warm: false });
await this.getPeerSignalMetrics();
break;
}
case "lxmf_message_created": {
this.onLxmfMessageCreated(json.lxmf_message);
- await this.getPeerPath();
+ await this.refreshPeerPath({ warm: false });
break;
}
case "lxmf_message_state_updated": {
@@ -3545,17 +3561,60 @@ export default {
});
}
},
- async getPeerPath() {
- if (this.selectedPeer) {
- try {
- const response = await getDestinationPath(window.api, this.selectedPeer.destination_hash, {});
- this.selectedPeerPath = response.data.path;
- } catch (e) {
- console.log(e);
- this.selectedPeerPath = null;
+ applyPeerPathSnapshot(snapshot, hash) {
+ if (!this._hexEqual(this.selectedPeer?.destination_hash, hash)) {
+ return;
+ }
+ this.peerPathSnapshot = snapshot ?? normalizePathSnapshot(null);
+ },
+ async refreshPeerPath(options = {}) {
+ const hash = options.hash ?? this.selectedPeer?.destination_hash;
+ if (!hash) {
+ return null;
+ }
+
+ const warm = options.warm === true;
+ const seq = ++this.peerPathRequestSequence;
+ this.peerPathLoading = true;
+
+ try {
+ let snapshot = await fetchPeerPathSnapshot(window.api, hash);
+ if (seq !== this.peerPathRequestSequence) {
+ return null;
+ }
+ this.applyPeerPathSnapshot(snapshot, hash);
+
+ if (warm) {
+ const { requested } = await warmPathIfNeeded(window.api, hash, snapshot);
+ if (requested) {
+ if (seq !== this.peerPathRequestSequence) {
+ return snapshot;
+ }
+ this.peerPathWarming = true;
+ snapshot = await fetchPeerPathSnapshot(window.api, hash);
+ if (seq === this.peerPathRequestSequence) {
+ this.applyPeerPathSnapshot(snapshot, hash);
+ }
+ }
+ }
+
+ return snapshot;
+ } catch (e) {
+ console.log(e);
+ if (seq === this.peerPathRequestSequence) {
+ this.applyPeerPathSnapshot(null, hash);
+ }
+ return null;
+ } finally {
+ if (seq === this.peerPathRequestSequence) {
+ this.peerPathLoading = false;
+ this.peerPathWarming = false;
}
}
},
+ async getPeerPath() {
+ await this.refreshPeerPath({ warm: false });
+ },
async getPeerLxmfStampInfo() {
if (this.selectedPeer) {
try {
@@ -3593,7 +3652,15 @@ export default {
}
},
onDestinationPathClick(path) {
- DialogUtils.alert(`${path.hops} ${path.hops === 1 ? "hop" : "hops"} away via ${path.next_hop_interface}`);
+ const snapshot = this.peerPathSnapshot;
+ const parts = [`${path.hops} ${path.hops === 1 ? "hop" : "hops"} away via ${path.next_hop_interface}`];
+ if (snapshot?.path_stale) {
+ parts.push(this.$t("messages.path_stale_hint"));
+ }
+ if (snapshot?.path_unresponsive) {
+ parts.push(this.$t("messages.path_unresponsive_hint"));
+ }
+ DialogUtils.alert(parts.join("\n\n"));
},
onStampInfoClick(stampInfo) {
const stampCost = stampInfo.stamp_cost;
@@ -4722,14 +4789,7 @@ export default {
return lxmfMessage.method === "opportunistic" && lxmfMessage.state === "failed";
},
async warmPathToPeer() {
- if (!this.selectedPeer?.destination_hash) {
- return;
- }
- try {
- await postRequestPath(window.api, this.selectedPeer.destination_hash);
- } catch (e) {
- console.log(e);
- }
+ await this.refreshPeerPath({ warm: true });
},
async runPathFinderQuickRequest() {
const hash = this.selectedPeer?.destination_hash;
@@ -4738,9 +4798,18 @@ export default {
}
this.pathfinderInProgress = true;
try {
+ let snapshot = this.peerPathSnapshot;
+ if (!snapshot) {
+ snapshot = await fetchPeerPathSnapshot(window.api, hash);
+ this.applyPeerPathSnapshot(snapshot, hash);
+ }
+ if (!pathNeedsRefresh(snapshot)) {
+ ToastUtils.info(this.$t("messages.path_already_available"));
+ return;
+ }
await runDestinationPathFinder(window.api, hash, "quick");
ToastUtils.success(this.$t("nomadnet.path_finder_request_sent"));
- await this.getPeerPath();
+ await this.refreshPeerPath({ warm: false });
} catch (e) {
console.error("path finder quick request failed", e);
ToastUtils.error(this.$t("nomadnet.path_finder_failed"));
@@ -4760,9 +4829,13 @@ export default {
});
if (path) {
ToastUtils.success(this.$t("nomadnet.path_finder_found"));
- this.selectedPeerPath = path;
+ this.applyPeerPathSnapshot(
+ normalizePathSnapshot({ path, path_stale: false, path_unresponsive: false }),
+ hash
+ );
} else {
ToastUtils.error(this.$t("nomadnet.path_finder_not_found"));
+ await this.refreshPeerPath({ warm: false });
}
} catch (e) {
console.error("path finder force find failed", e);
@@ -4782,7 +4855,7 @@ export default {
onDropPathError: (e) => console.warn("drop-path failed (continuing)", e),
});
ToastUtils.success(this.$t("nomadnet.path_finder_dropped_and_requested"));
- await this.getPeerPath();
+ await this.refreshPeerPath({ warm: false });
} catch (e) {
console.error("path finder drop+request failed", e);
ToastUtils.error(this.$t("nomadnet.path_finder_failed"));
@@ -5129,6 +5202,7 @@ export default {
_preview_url: previewUrl,
};
}
+ const needsPathfinding = pathNeedsRefresh(this.peerPathSnapshot);
this.chatItems.push({
type: "lxmf_message",
lxmf_message: {
@@ -5141,7 +5215,7 @@ export default {
source_hash: job.myLxmfAddressHash,
fields: Object.keys(pendingFields).length > 0 ? pendingFields : undefined,
reply_to_hash: job.replyToHash,
- _pendingPathfinding: true,
+ _pendingPathfinding: needsPathfinding,
},
is_outbound: true,
});
@@ -5229,6 +5303,7 @@ export default {
}
this.scrollMessagesToBottom();
+ this.refreshPeerPath({ warm: false });
} catch (e) {
this.removePendingOutboundPlaceholder(job.pendingHash);
const message = e.response?.data?.message ?? "failed to send message";

diff --git a/meshchatx/src/frontend/js/reticulumPathfinding.js b/meshchatx/src/frontend/js/reticulumPathfinding.js
index 91806552..b274248e 100644
--- a/meshchatx/src/frontend/js/reticulumPathfinding.js
+++ b/meshchatx/src/frontend/js/reticulumPathfinding.js
@@ -1,5 +1,78 @@
const destinationPath = (hash) => `/api/v1/destination/${hash}/path`;
+/**
+ * @typedef {{ path: object | null, path_stale: boolean, path_unresponsive: boolean }} PeerPathSnapshot
+ */
+
+/**
+ * @param {unknown} data
+ * @returns {PeerPathSnapshot}
+ */
+export function normalizePathSnapshot(data) {
+ if (!data || typeof data !== "object") {
+ return { path: null, path_stale: true, path_unresponsive: false };
+ }
+ const row = /** @type {{ path?: object | null, path_stale?: boolean, path_unresponsive?: boolean }} */ (data);
+ const path = row.path ?? null;
+ return {
+ path,
+ path_stale: path == null ? true : Boolean(row.path_stale),
+ path_unresponsive: Boolean(row.path_unresponsive),
+ };
+}
+
+/**
+ * @param {PeerPathSnapshot | null | undefined} snapshot
+ */
+export function pathNeedsRefresh(snapshot) {
+ if (!snapshot) {
+ return true;
+ }
+ if (!snapshot.path) {
+ return true;
+ }
+ if (snapshot.path_stale) {
+ return true;
+ }
+ if (snapshot.path_unresponsive) {
+ return true;
+ }
+ return false;
+}
+
+/**
+ * @param {PeerPathSnapshot | null | undefined} snapshot
+ */
+export function pathIsReady(snapshot) {
+ return Boolean(snapshot?.path && !snapshot.path_stale && !snapshot.path_unresponsive);
+}
+
+/**
+ * @param {import("axios").AxiosInstance} api
+ * @param {string} hash
+ * @returns {Promise<PeerPathSnapshot>}
+ */
+export async function fetchPeerPathSnapshot(api, hash) {
+ const res = await getDestinationPath(api, hash, {});
+ return normalizePathSnapshot(res.data);
+}
+
+/**
+ * Request a mesh path refresh only when the current snapshot is missing or stale.
+ *
+ * @param {import("axios").AxiosInstance} api
+ * @param {string} hash
+ * @param {PeerPathSnapshot | null | undefined} snapshot
+ * @returns {Promise<{ requested: boolean }>}
+ */
+export async function warmPathIfNeeded(api, hash, snapshot) {
+ if (!pathNeedsRefresh(snapshot)) {
+ return { requested: false };
+ }
+ await postRequestPath(api, hash);
+ return { requested: true };
+}
+
/**
* @param {import("axios").AxiosInstance} api
* @param {string} hash

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 06729972..86be304d 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1244,6 +1244,15 @@
"outbound_solving_stamps_short": "Stempel berechnen",
"outbound_pathfinding_tooltip": "Pfad wird gesucht: Reticulum ermittelt eine Route zu diesem Peer. Ihre Nachricht wird gesendet, sobald die Route bereit ist.",
"outbound_pathfinding_short": "Pfad wird gesucht",
+ "path_no_route": "Kein Pfad",
+ "path_no_route_hint": "Noch keine Route zu diesem Peer. Nutzen Sie den Pfadfinder oder senden Sie eine Nachricht.",
+ "path_loading": "Pfad wird geprüft…",
+ "path_stale_label": "veraltet",
+ "path_unresponsive_label": "ohne Antwort",
+ "path_stale_hint": "Dieser Pfadeintrag ist abgelaufen und wird vor dem nächsten Senden ggf. neu ermittelt.",
+ "path_unresponsive_hint": "Reticulum hat diesen Pfad als nicht ansprechbar markiert.",
+ "path_info_title": "Pfadinformation",
+ "path_already_available": "Pfad ist bereits verfügbar",
"outbound_preparing_message": "Nachricht wird vorbereitet…",
"outbound_preparing_message_short": "Nachricht vorbereiten",
"outbound_sending_with_progress": "Wird gesendet… {progress}%",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 30ae3869..6bf4488a 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1210,6 +1210,15 @@
"outbound_solving_stamps_short": "Solving stamps",
"outbound_pathfinding_tooltip": "Finding path: Reticulum is resolving a route to this peer. Your message sends when the route is ready.",
"outbound_pathfinding_short": "Finding path",
+ "path_no_route": "No path",
+ "path_no_route_hint": "No route to this peer yet. Use the path finder or send a message to discover one.",
+ "path_loading": "Checking path…",
+ "path_stale_label": "stale",
+ "path_unresponsive_label": "unresponsive",
+ "path_stale_hint": "This path entry is expired and may be rediscovered before the next send.",
+ "path_unresponsive_hint": "Reticulum marked this path as unresponsive.",
+ "path_info_title": "Path information",
+ "path_already_available": "Path is already available",
"outbound_preparing_message": "Preparing message…",
"outbound_preparing_message_short": "Preparing message",
"outbound_sending_with_progress": "Sending… {progress}%",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 4d889d59..9f17544d 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1210,6 +1210,15 @@
"outbound_solving_stamps_short": "Calculando sellos",
"outbound_pathfinding_tooltip": "Buscando ruta: Reticulum está resolviendo una ruta a este par. Su mensaje se enviará cuando la ruta esté lista.",
"outbound_pathfinding_short": "Buscando ruta",
+ "path_no_route": "Sin ruta",
+ "path_no_route_hint": "Aún no hay ruta a este par. Use el buscador de rutas o envíe un mensaje para descubrirla.",
+ "path_loading": "Comprobando ruta…",
+ "path_stale_label": "obsoleta",
+ "path_unresponsive_label": "sin respuesta",
+ "path_stale_hint": "Esta entrada de ruta ha expirado y puede redescubrirse antes del próximo envío.",
+ "path_unresponsive_hint": "Reticulum marcó esta ruta como sin respuesta.",
+ "path_info_title": "Información de ruta",
+ "path_already_available": "La ruta ya está disponible",
"outbound_preparing_message": "Preparando mensaje…",
"outbound_preparing_message_short": "Preparando mensaje",
"outbound_sending_with_progress": "Enviando… {progress}%",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 9009ecf1..205b79ad 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -1210,6 +1210,15 @@
"outbound_solving_stamps_short": "Solving stamps",
"outbound_pathfinding_tooltip": "Finding path: Reticulum is resolving a route to this peer. Your message sends when the route is ready.",
"outbound_pathfinding_short": "Finding path",
+ "path_no_route": "No path",
+ "path_no_route_hint": "No route to this peer yet. Use the path finder or send a message to discover one.",
+ "path_loading": "Checking path…",
+ "path_stale_label": "stale",
+ "path_unresponsive_label": "unresponsive",
+ "path_stale_hint": "This path entry is expired and may be rediscovered before the next send.",
+ "path_unresponsive_hint": "Reticulum marked this path as unresponsive.",
+ "path_info_title": "Path information",
+ "path_already_available": "Path is already available",
"outbound_preparing_message": "Preparing message…",
"outbound_preparing_message_short": "Preparing message",
"outbound_sending_with_progress": "Sending… {progress}%",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 34a8d861..e5161ee6 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1210,6 +1210,15 @@
"outbound_solving_stamps_short": "Calcul des timbres",
"outbound_pathfinding_tooltip": "Recherche d'itinéraire : Reticulum résout une route vers ce pair. Votre message sera envoyé lorsque la route sera prête.",
"outbound_pathfinding_short": "Recherche d'itinéraire",
+ "path_no_route": "Aucun chemin",
+ "path_no_route_hint": "Aucune route vers ce pair pour l'instant. Utilisez le chercheur de chemin ou envoyez un message pour en découvrir une.",
+ "path_loading": "Vérification du chemin…",
+ "path_stale_label": "expiré",
+ "path_unresponsive_label": "sans réponse",
+ "path_stale_hint": "Cette entrée de chemin a expiré et peut être redécouverte avant le prochain envoi.",
+ "path_unresponsive_hint": "Reticulum a marqué ce chemin comme sans réponse.",
+ "path_info_title": "Informations sur le chemin",
+ "path_already_available": "Le chemin est déjà disponible",
"outbound_preparing_message": "Préparation du message…",
"outbound_preparing_message_short": "Préparation du message",
"outbound_sending_with_progress": "Envoi… {progress}%",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 468de85c..0830c849 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1244,6 +1244,15 @@
"outbound_solving_stamps_short": "Calcolo francobolli",
"outbound_pathfinding_tooltip": "Ricerca percorso: Reticulum sta risolvendo un percorso verso questo peer. Il messaggio verrà inviato quando il percorso sarà pronto.",
"outbound_pathfinding_short": "Ricerca percorso",
+ "path_no_route": "Nessun percorso",
+ "path_no_route_hint": "Nessuna rotta verso questo peer. Usa il cercatore percorsi o invia un messaggio per scoprirne una.",
+ "path_loading": "Verifica percorso…",
+ "path_stale_label": "obsoleto",
+ "path_unresponsive_label": "non responsivo",
+ "path_stale_hint": "Questa voce di percorso è scaduta e può essere riscoperta prima del prossimo invio.",
+ "path_unresponsive_hint": "Reticulum ha contrassegnato questo percorso come non responsivo.",
+ "path_info_title": "Informazioni percorso",
+ "path_already_available": "Il percorso è già disponibile",
"outbound_preparing_message": "Preparazione messaggio…",
"outbound_preparing_message_short": "Preparazione messaggio",
"outbound_sending_with_progress": "Invio… {progress}%",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 03e5d2a4..2b860f04 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1210,6 +1210,15 @@
"outbound_solving_stamps_short": "Stempels berekenen",
"outbound_pathfinding_tooltip": "Pad zoeken: Reticulum zoekt een route naar deze peer. Uw bericht wordt verzonden zodra de route klaar is.",
"outbound_pathfinding_short": "Pad zoeken",
+ "path_no_route": "Geen pad",
+ "path_no_route_hint": "Nog geen route naar deze peer. Gebruik de padzoeker of stuur een bericht om er een te vinden.",
+ "path_loading": "Pad controleren…",
+ "path_stale_label": "verouderd",
+ "path_unresponsive_label": "niet responsief",
+ "path_stale_hint": "Dit pad is verlopen en kan opnieuw worden ontdekt vóór het volgende verzenden.",
+ "path_unresponsive_hint": "Reticulum heeft dit pad als niet responsief gemarkeerd.",
+ "path_info_title": "Padinformatie",
+ "path_already_available": "Pad is al beschikbaar",
"outbound_preparing_message": "Bericht voorbereiden…",
"outbound_preparing_message_short": "Bericht voorbereiden",
"outbound_sending_with_progress": "Verzenden… {progress}%",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index eba503fd..dc2a5931 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1244,6 +1244,15 @@
"outbound_solving_stamps_short": "Вычисление марок",
"outbound_pathfinding_tooltip": "Поиск пути: Reticulum определяет маршрут к этому узлу. Сообщение будет отправлено, когда маршрут будет готов.",
"outbound_pathfinding_short": "Поиск пути",
+ "path_no_route": "Нет пути",
+ "path_no_route_hint": "Маршрут к этому узлу ещё не найден. Используйте поиск пути или отправьте сообщение.",
+ "path_loading": "Проверка пути…",
+ "path_stale_label": "устарел",
+ "path_unresponsive_label": "не отвечает",
+ "path_stale_hint": "Запись пути истекла и может быть обновлена перед следующей отправкой.",
+ "path_unresponsive_hint": "Reticulum пометил этот путь как неотвечающий.",
+ "path_info_title": "Информация о пути",
+ "path_already_available": "Путь уже доступен",
"outbound_preparing_message": "Подготовка сообщения…",
"outbound_preparing_message_short": "Подготовка сообщения",
"outbound_sending_with_progress": "Отправка… {progress}%",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index e072fbb4..8466f81e 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1210,6 +1210,15 @@
"outbound_solving_stamps_short": "计算邮戳",
"outbound_pathfinding_tooltip": "正在查找路径:Reticulum 正在解析到该节点的路由。路由就绪后消息将发送。",
"outbound_pathfinding_short": "正在查找路径",
+ "path_no_route": "无路径",
+ "path_no_route_hint": "尚无到此节点的路由。使用路径查找器或发送消息以发现路由。",
+ "path_loading": "正在检查路径…",
+ "path_stale_label": "已过期",
+ "path_unresponsive_label": "无响应",
+ "path_stale_hint": "此路径条目已过期,下次发送前可能会重新发现。",
+ "path_unresponsive_hint": "Reticulum 已将此路径标记为无响应。",
+ "path_info_title": "路径信息",
+ "path_already_available": "路径已可用",
"outbound_preparing_message": "正在准备消息…",
"outbound_preparing_message_short": "准备消息",
"outbound_sending_with_progress": "正在发送… {progress}%",

diff --git a/tests/frontend/ConversationPeerHeader.test.js b/tests/frontend/ConversationPeerHeader.test.js
new file mode 100644
index 00000000..3a87b63b
--- /dev/null
+++ b/tests/frontend/ConversationPeerHeader.test.js
@@ -0,0 +1,61 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect } from "vitest";
+import ConversationPeerHeader from "../../meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue";
+
+const peer = {
+ destination_hash: "a".repeat(32),
+ display_name: "Test Peer",
+};
+
+function mountHeader(props = {}) {
+ return mount(ConversationPeerHeader, {
+ props: {
+ selectedPeer: peer,
+ ...props,
+ },
+ global: {
+ mocks: { $t: (key) => key },
+ stubs: [
+ "MaterialDesignIcon",
+ "IconButton",
+ "DropDownMenu",
+ "DropDownMenuItem",
+ "LxmfUserIcon",
+ "ConversationDropDownMenu",
+ ],
+ },
+ });
+}
+
+describe("ConversationPeerHeader.vue path row", () => {
+ it("shows hop count for a fresh path", () => {
+ const wrapper = mountHeader({
+ selectedPeerPath: { hops: 2, next_hop: "b".repeat(32), next_hop_interface: "UDP" },
+ peerPathSnapshot: { path: { hops: 2 }, path_stale: false, path_unresponsive: false },
+ });
+ expect(wrapper.text()).toContain("messages.hops_away");
+ });
+
+ it("shows no path when snapshot has no route", () => {
+ const wrapper = mountHeader({
+ selectedPeerPath: null,
+ peerPathSnapshot: { path: null, path_stale: true, path_unresponsive: false },
+ });
+ expect(wrapper.text()).toContain("messages.path_no_route");
+ });
+
+ it("shows finding path while warming", () => {
+ const wrapper = mountHeader({
+ peerPathWarming: true,
+ });
+ expect(wrapper.text()).toContain("messages.outbound_pathfinding_short");
+ });
+
+ it("marks stale paths in the label", () => {
+ const wrapper = mountHeader({
+ selectedPeerPath: { hops: 1, next_hop: "b".repeat(32), next_hop_interface: "UDP" },
+ peerPathSnapshot: { path: { hops: 1 }, path_stale: true, path_unresponsive: false },
+ });
+ expect(wrapper.text()).toContain("messages.path_stale_label");
+ });
+});

diff --git a/tests/frontend/reticulumPathfinding.test.js b/tests/frontend/reticulumPathfinding.test.js
index 68361cac..44277d2a 100644
--- a/tests/frontend/reticulumPathfinding.test.js
+++ b/tests/frontend/reticulumPathfinding.test.js
@@ -6,6 +6,10 @@ import {
postRequestPath,
postDropPath,
runDestinationPathFinder,
+ normalizePathSnapshot,
+ pathNeedsRefresh,
+ pathIsReady,
+ warmPathIfNeeded,
} from "../../meshchatx/src/frontend/js/reticulumPathfinding.js";
describe("reticulumPathfinding.js", () => {
@@ -42,4 +46,40 @@ describe("reticulumPathfinding.js", () => {
const api = { get: vi.fn(), post: vi.fn() };
await expect(runDestinationPathFinder(api, "h", "invalid-mode")).rejects.toThrow("unknown path finder mode");
});
+
+ it("normalizePathSnapshot defaults missing metadata to stale", () => {
+ expect(normalizePathSnapshot({ path: null })).toEqual({
+ path: null,
+ path_stale: true,
+ path_unresponsive: false,
+ });
+ expect(normalizePathSnapshot(null)).toEqual({
+ path: null,
+ path_stale: true,
+ path_unresponsive: false,
+ });
+ });
+
+ it("pathNeedsRefresh respects stale and unresponsive flags", () => {
+ const fresh = { path: { hops: 1 }, path_stale: false, path_unresponsive: false };
+ expect(pathNeedsRefresh(fresh)).toBe(false);
+ expect(pathIsReady(fresh)).toBe(true);
+ expect(pathNeedsRefresh({ ...fresh, path_stale: true })).toBe(true);
+ expect(pathNeedsRefresh({ ...fresh, path_unresponsive: true })).toBe(true);
+ expect(pathNeedsRefresh({ path: null, path_stale: true, path_unresponsive: false })).toBe(true);
+ });
+
+ it("warmPathIfNeeded skips request when path is ready", async () => {
+ const api = { post: vi.fn() };
+ const snapshot = { path: { hops: 1 }, path_stale: false, path_unresponsive: false };
+ const r = await warmPathIfNeeded(api, "abc", snapshot);
+ expect(r).toEqual({ requested: false });
+ expect(api.post).not.toHaveBeenCalled();
+ });
+
+ it("warmPathIfNeeded posts when path is missing or stale", async () => {
+ const api = { post: vi.fn().mockResolvedValue({}) };
+ await warmPathIfNeeded(api, "abc", { path: null, path_stale: true, path_unresponsive: false });
+ expect(api.post).toHaveBeenCalledWith("/api/v1/destination/abc/request-path");
+ });
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────